Skip to main content

2D UI in a 3D Scene

Not every game interface belongs in a screen-space HUD. Sometimes you want a nameplate floating above a character, a terminal screen on a spaceship wall, or a health bar that moves with the player. These elements should exist in the 3D world and stay readable from the camera's perspective. Surface3D lets an ordinary 2D node tree do exactly that.

This tutorial builds a 2D label, gives it a world position via Surface3D, and chooses a billboard mode that controls how it faces the camera. By the end you'll understand the two distinct sizes that control a surface's appearance, and you'll know which billboard mode fits each use case.

Tutorial goal

Attach a 2D label to the 3D scene, make it face the camera on the vertical axis, and choose an appropriate backing resolution.

Picking the right UI type

Use a normal 2D node tree for UI that must remain fixed to the screen. Use Surface3D when the content has a world position and should obey 3D depth, perspective, and camera movement.

UI exampleBest home
Pause menu or permanent HUDNormal 2D scene tree
NPC nameplate or floating promptSurface3D with Billboard.YAxis or Billboard.Screen
In-world terminal or signSurface3D with Billboard.None

1. Build the 2D content

The content is an ordinary Node. The 2D content is still a normal Node tree; Surface3D doesn't change how you write labels or build your UI. Start with a label so you can focus on the 3D placement.

init.ts
import {Billboard, Director, Label, Size, Surface3D, Vec3} from "Dora";

const view = Director.entry;
const label = Label("sarasa-mono-sc-regular", 24)!;
label.text = "Hello from 2D";
  • Label(fontName, fontSize) creates a text node with the specified font and size.
  • The ! operator in TypeScript asserts that the result is not null. In Lua and YueScript, the constructor may return nothing if the font is missing, so real projects should check for nil.
  • label.text = ... sets the displayed text. All four languages use the same dot syntax for this property assignment.

2. Give the 2D node a world-space surface

Surface3D bridges 2D content into the 3D world. It has two distinct sizes: the world size controls how large the panel appears in the scene, while the pixel size controls the resolution of the backing texture for detail. The two sizes are deliberately different concepts, so you can tune appearance and performance independently.

init.ts
const surface = Surface3D(label, Size(3, 1), Size(512, 128));
if (!surface) throw new Error("failed to create Surface3D");

surface.position = Vec3(0, 1.5, 0);
surface.billboard = Billboard.YAxis;
view.addChild(surface);
  • Surface3D(label, Size(3, 1), Size(512, 128)) creates the surface. The first argument is the 2D node to wrap. The second argument, Size(3, 1), is the physical size in the 3D world: 3 metres wide and 1 metre tall. The third argument, Size(512, 128), is the pixel size of the backing texture: 512 pixels wide by 128 pixels tall.
  • The error check handles the case where surface creation fails. In TypeScript, a null check throws an error; in Lua, an explicit check with if not surface then error(...) end; in YueScript, the error ... unless surface pattern; in Teal, the type assertion ensures type safety.
  • surface.position = Vec3(0, 1.5, 0) places the surface in world space. Here it sits at the origin horizontally, 1.5 metres up, and zero depth.
  • surface.billboard = Billboard.YAxis (or "YAxis" in Lua/Yue/Teal) sets the billboard mode. The YAxis mode makes the surface rotate around the vertical axis to face the camera, but it doesn't tilt up or down.
  • view.addChild(surface) (or view.scene:addChild(surface) in Teal) attaches the surface to the scene. Like any unattached node, it would be mounted automatically under Director.entry, but attaching explicitly makes the hierarchy clear.

Checkpoint

Move the camera around the label. With Billboard.YAxis, the label should turn left and right toward the camera but should not tilt up and down. If it is too small, adjust world size first; if text is blurry, adjust pixel size second.

3. Choose a billboard mode

Surface3D automatically selects a shared-depth or isolated-texture path. The usingTexture property can help you diagnose rendering behavior, but it's read-only and you normally don't need to set it directly. The important choice is the billboard mode, which controls how the surface orients itself relative to the camera.

  • Billboard.None: preserve the surface's 3D rotation. Use this for fixed signs and terminals that are physically anchored in the world.
  • Billboard.YAxis: face the camera around the vertical axis. Use this for nameplates and labels that should stay upright but track the player horizontally.
  • Billboard.Screen: face the camera fully. Use this for prompts that must stay readable from any angle, such as interactive markers or floating instructions.

Performance tips

When working with many surfaces in a scene, keep these guidelines in mind:

  1. Don't create a new Surface3D every frame. Reuse one surface and update its label or child nodes instead. Creating surfaces repeatedly can cause performance issues due to texture allocation.
  2. Don't default every panel to a huge backing texture. Start with the smallest size that keeps text legible at its intended camera distance. Large textures cost memory and GPU time.

When a scene contains many panels, compare view.stats before and after changing their backing resolutions to see the performance impact. Draw calls and texture memory are good metrics to watch.

Complete examples

The complete scripts below follow this tutorial and have been verified by the Dora engine.

init.ts
import {Billboard, Camera3D, Director, Label, Size, Surface3D, Vec3} from "Dora";

const view = Director.entry;

const camera = Camera3D();
camera.lookAt(Vec3(0, 1.5, 5), Vec3(0, 1.5, 0));
Director.pushCamera(camera);

// Ordinary 2D content: just a Label.
const label = Label("sarasa-mono-sc-regular", 24);
if (!label) throw new Error("failed to create label");
label.text = "Hello from 2D";

// Surface3D places that 2D content into the 3D world.
// Size(3, 1) -> world size: 3 metres wide, 1 metre tall.
// Size(512, 128) -> backing texture resolution in pixels.
const surface = Surface3D(label, Size(3, 1), Size(512, 128));
if (!surface) throw new Error("failed to create Surface3D");
surface.position = Vec3(0, 1.5, 0);
surface.billboard = Billboard.YAxis;
view.addChild(surface);

Try it yourself

Replace the label with a small 2D Node subtree containing a title and a status line. Attach it above the falling crate from the physics tutorial. Then toggle between Billboard.YAxis and Billboard.None to feel the difference between an information label and a physical sign.

Summary

You now know how to place 2D UI elements in a 3D scene:

  • Choice of UI type: Use normal 2D nodes for screen-space HUDs and Surface3D for world-space elements like nameplates, terminals, and signs.
  • Creating a surface: Surface3D(label, worldSize, pixelSize) wraps a 2D node and gives it a position in 3D world space.
  • Two sizes: World size controls how large the panel appears in the scene; pixel size controls the backing texture resolution for detail.
  • Billboard modes: Billboard.None for fixed signs, Billboard.YAxis for nameplates that track horizontally, and Billboard.Screen for prompts that always face the camera.
  • Performance: Reuse surfaces and use reasonable pixel sizes. Compare view.stats to measure the impact of texture choices.

With these tools, you can create nameplates that follow characters, terminals on spaceship walls, and any other UI that needs to exist in your 3D world.

Next tutorial

Continue with 3D performance and debugging to make this scene measurable before you add more content.